Skip to content

fix: abort in-flight request handlers on connection close#1735

Merged
felixweinberger merged 10 commits intomainfrom
fweinberger/abort-handlers-on-close
Mar 27, 2026
Merged

fix: abort in-flight request handlers on connection close#1735
felixweinberger merged 10 commits intomainfrom
fweinberger/abort-handlers-on-close

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

Aborts active request handlers when the transport connection closes, and makes InMemoryTransport.close() idempotent.

Salvaged from #833 by @alasano, ported to the v2 package structure with the Protocol tests that were requested in review.

Motivation and Context

When a client disconnects mid-request (network failure, timeout, crash), the server's Protocol._onclose() cleans up response handlers but leaves in-flight request handlers running. Long-running operations (file uploads, external API calls, elicitation prompts) continue indefinitely, wasting resources and causing hangs.

Separately, InMemoryTransport.close() recurses through the peer and fires onclose twice on the initiating side.

Fixes #611. Supersedes #833.

How Has This Been Tested?

  • New Protocol test verifying request handler AbortSignal fires with ConnectionClosed on transport close
  • New InMemoryTransport tests for single-fire, double-close idempotency, and concurrent close from both sides
  • All 444 core tests pass

Breaking Changes

None. Request handlers that ignore ctx.mcpReq.signal are unaffected; handlers that respect it will now abort cleanly instead of running to completion after disconnect.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

The abort controllers infrastructure already existed for explicit notifications/cancelled handling; this connects the same mechanism to connection close events.

Previously, request handlers would continue running after the transport
disconnected, wasting resources and preventing cleanup of long-running
operations. Protocol._onclose() now aborts all active request handler
AbortControllers with a ConnectionClosed error.

Also fixes InMemoryTransport.close() firing onclose twice on the
initiating side due to peer recursion.

Fixes #611

Co-authored-by: Aljosa Asanovic <aljosa.a@gmail.com>
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 23, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1735

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1735

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1735

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1735

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1735

commit: ff24774

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — small, well-tested fix that connects existing abort controller infrastructure to the connection close path.

Extended reasoning...

Overview

This PR makes two small, focused changes: (1) in Protocol._onclose(), it aborts all in-flight request handler abort controllers when the connection closes, and (2) it makes InMemoryTransport.close() idempotent with a _closed guard. Both changes are accompanied by new tests.

Security risks

No security concerns. The changes are purely about resource cleanup on disconnect — no auth, crypto, or permission logic is involved.

Level of scrutiny

The protocol.ts change adds 6 lines that follow the exact same save-replace-iterate pattern already used for _responseHandlers cleanup 3 lines above. The InMemoryTransport change is a standard idempotency guard (3 lines). Both are mechanical and low-risk. The two bug reports found are pre-existing issues (timeout handle leak in _onclose) and a very low-impact edge case in a test utility (InMemoryTransport.close missing try-finally), neither of which are regressions from this PR.

Other factors

The abort controller infrastructure (_requestHandlerAbortControllers map, creation in _onrequest, cleanup in .finally(), cancellation via notifications/cancelled) already existed — this PR simply connects the same mechanism to the close event. Test coverage is good: the protocol test verifies the abort signal fires with ConnectionClosed, and three InMemoryTransport tests cover single-fire, double-close, and concurrent close scenarios.

… throws

- Clear _timeoutInfo and _requestResolvers in _onclose() to prevent
  leaked setTimeout handles from keeping the event loop alive
- Wrap peer close in try-finally so InMemoryTransport.onclose fires
  even if the peer's onclose callback throws
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 25, 2026

🦋 Changeset detected

Latest commit: ff24774

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous feedback addressed, LGTM.

Extended reasoning...

Overview

This PR adds abort controller cleanup to Protocol._onclose() so in-flight request handlers are aborted on disconnect, and makes InMemoryTransport.close() idempotent with a _closed guard and try/finally for robustness. It also opportunistically cleans up _timeoutInfo and _requestResolvers on close. Five files are modified: the two source files, their corresponding test files, and a changeset.

Security risks

None. The changes are purely internal cleanup logic — no auth, crypto, or permission changes.

Level of scrutiny

This is a well-scoped bug fix that extends an existing cleanup pattern (clearing maps and aborting controllers in _onclose) to cover a previously-missed case. The abort controller infrastructure already existed for notifications/cancelled; this just connects the same mechanism to connection close. The InMemoryTransport changes are to a test utility. Both changes are low-risk.

Other factors

The author addressed both of my previous review comments (timeout cleanup and try/finally in InMemoryTransport) in commit 3b88756. The new test coverage is thorough: protocol-level abort signal test, and InMemoryTransport tests for single-fire, double-close idempotency, concurrent close, and error propagation. The remaining nit (try/finally around this.onclose?.() in _onclose()) is pre-existing and minor — it does not block approval.

Wrap the user-provided onclose callback in try/finally so response
handler rejection and abort controller firing happen even if the
callback throws. Consistent with the pattern in InMemoryTransport.
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All previous review feedback has been addressed — LGTM.

Extended reasoning...

Overview

This PR makes two targeted changes to @modelcontextprotocol/core:

  1. Protocol._onclose() now aborts in-flight request handler AbortControllers on connection close, clears _timeoutInfo (fixing a pre-existing timer leak), clears _requestResolvers, and wraps the user onclose callback in try/finally so cleanup always runs.
  2. InMemoryTransport.close() is made idempotent via a _closed flag and uses try/finally to ensure onclose fires even if the peer throws.

Five files are touched: protocol.ts, inMemory.ts, their respective test files, and a changeset.

Security risks

None. The changes are internal cleanup logic with no auth, crypto, or permission implications.

Level of scrutiny

The core change — aborting request handler abort controllers on close — leverages an existing mechanism (the _requestHandlerAbortControllers map, already used for notifications/cancelled). The new code is ~15 lines of cleanup logic in _onclose() plus an idempotency guard in InMemoryTransport. Both are well-tested with 5 new test cases covering the abort signal, single-fire, double-close, concurrent close, and peer-throws scenarios.

Other factors

This PR went through three rounds of review from me. All feedback — try/finally in InMemoryTransport.close(), clearing _timeoutInfo/_requestResolvers in _onclose(), and try/finally around the user onclose callback — has been addressed in subsequent commits. Both prior inline comments are marked as resolved. The changes are non-breaking (handlers that ignore signal are unaffected) and the changeset is correctly configured as a patch release.

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all previous review feedback addressed, cleanup logic is correct and well-tested.

Extended reasoning...

Overview

This PR touches two files in packages/core/src: protocol.ts (the core Protocol class _onclose() method) and inMemory.ts (the InMemoryTransport.close() method), plus corresponding test files. The changes abort in-flight request handler AbortControllers on connection close, make InMemoryTransport.close() idempotent with a _closed guard, and clean up previously leaked _timeoutInfo and _requestResolvers maps.

Security risks

None. The changes are internal cleanup logic for connection lifecycle management. No auth, crypto, permissions, or external-facing code is modified.

Level of scrutiny

This is core protocol infrastructure, which normally warrants careful review. However, all three rounds of review feedback (try-finally in InMemoryTransport, try-finally in Protocol._onclose, and timeout/resolver cleanup) have been addressed in follow-up commits. The changes follow established patterns already present in the codebase (abort controllers for notifications/cancelled, response handler cleanup in _onclose). The behavioral change is opt-in — only handlers that already listen to ctx.mcpReq.signal are affected.

Other factors

Five new tests cover the critical scenarios: single-fire onclose, double-close idempotency, concurrent close, peer-throws-still-fires-onclose, and abort-signal-on-connection-close. The changeset is included. All previous inline comments are resolved with corresponding code changes.

felixweinberger and others added 2 commits March 25, 2026 20:40
…ctor

The TaskManager extraction moved _requestResolvers to the new class but
the merge left a stale reference in Protocol._onclose. Relocate the
cleanup to TaskManager.onClose alongside _taskProgressTokens.
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All previous feedback addressed — LGTM.

Extended reasoning...

Overview

This PR adds abort signal firing for in-flight request handlers when the transport connection closes, cleans up timeout handles and request resolvers on close, and makes InMemoryTransport.close() idempotent. Files modified: protocol.ts, taskManager.ts, inMemory.ts, plus corresponding tests.

Security risks

No security risks. The changes improve resource cleanup on disconnect, which is purely defensive.

Level of scrutiny

Moderate scrutiny was warranted due to the changes touching Protocol lifecycle cleanup (_onclose). The core logic is straightforward: snapshot-and-clear maps, then iterate the snapshots. The author addressed all three issues raised in previous reviews: the critical _requestResolvers misplacement (moved to TaskManager.onClose()), the _timeoutInfo leak (now cleared with clearTimeout), and the try/finally wrapping of this.onclose?.() for defensive cleanup.

Other factors

The PR includes comprehensive test coverage: abort signal firing on close, single-fire/double-close/concurrent-close idempotency for InMemoryTransport, and a test for onclose firing even when the peer throws. All 444 core tests pass per the author. The changes follow existing patterns in the codebase and have no breaking changes.

Copy link
Copy Markdown
Contributor

@bhosmer-ant bhosmer-ant left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fix — addresses the core issue and all prior review feedback cleanly. Two things worth tightening up before merge (both small, both keep the PR's safety guarantees consistent):

Copy link
Copy Markdown
Contributor

@bhosmer-ant bhosmer-ant left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upgrading my earlier comment to request-changes so you can re-request when ready — the two inline findings there (missing try/finally in connect()'s onclose wrapper, and the map-swap stale-reference race with _onrequest's .finally() cleanup) are both small fixes.

@KKonstantinov
Copy link
Copy Markdown
Contributor

Hi, This also is somewhat related to #1283 which I was looking at, where notification() throws Not connected`, crashing the Node process after a client disconnects mid-tool-execution. That lead me to explore this further and seeing this PR. Think #1283 has to wait for this PR to go in first, and then be reviewed again.

…ler delete

- Wrap preserved transport onclose in try/finally so _onclose() runs
  even if the transport-level handler throws
- Add identity check before deleting from _requestHandlerAbortControllers
  in the .finally() cleanup to avoid deleting a reconnected request's
  controller when IDs collide across close/reconnect
@felixweinberger felixweinberger dismissed bhosmer-ant’s stale review March 26, 2026 13:51

Addressed requested changes

The override prevented the base class _requestResolvers cleanup from
running. Base TaskManager.onClose just clears two maps, which is cheap
and correct for the null variant too.
Copy link
Copy Markdown
Contributor

@bhosmer-ant bhosmer-ant left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both findings addressed — the try/finally in connect() and the identity check in the .finally() cleanup are exactly right. LGTM.

Two observations for possible follow-up (non-blocking, both about interactions with pre-existing code):

Task result loss on close: The new abort-on-close can race with task result persistence. The pre-existing abort check in _onrequest (~lines 621-624, 639-642) does if (signal.aborted) return; before routeResponse(). Previously the abort only came from explicit notifications/cancelled; now it also comes from close. If a handler completes and produces a task result, but close fires before routeResponse runs, the result is dropped — which matters for task resumability across reconnections. Narrow window and tasks are experimental, so not urgent, but the fix would be to check signal.reason and still call routeResponse (skip only the capturedTransport.send) when the reason is ConnectionClosed.

_onclose itself isn't idempotent: The _closed flag on InMemoryTransport is the right fix for that transport, but Protocol._onclose() has no guard of its own. If some other transport double-fires onclose, this.onclose?.() runs twice. The map swaps make the second run mostly a no-op, but the user callback isn't. Belt-and-suspenders would be an early-return if _transport is already undefined.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Request handlers not cancelled when transport connection closes unexpectedly

3 participants